flutter - GestureBinding 与触摸事件分发
前言
flutter 做为一个移动端的框架,触摸事件的分发是其中一个非常重要而且复杂的流程,详细地了解分发流程
对于交互功能的开发帮助十分巨大。flutter 接受来自引擎的触摸事,那我们从这之后 flutter 的 framework 层面开始分析触摸事件的分发流程。
事件源头
flutter 工程从 runApp 开始
1 | void runApp(Widget app) { |
WidgetsFlutterBinding 作为功能代理将绑定从 flutter 引擎来的各种事件,该类混入了各种 Binding 功能类 —— GestureBinding、ServicesBinding、SchedulerBinding、PaintingBinding、RendererBinding、WidgetsBinding,这次的主角则是 GestureBinding —— 顾名思义是用来绑定手势功能的类
GestureBinding 初始化方法
1 | @override |
GestureBinding 初始化方法,由 WidgetsFlutterBinding 继承的 BindingBase 类的构造方法中调用。在该类的初始化工作中,会将 GestureBinding._handlePointerDataPacket 方法作为回调方法传递给 ui.window.onPointerDataPacket,ui.window 是作为 flutter 引擎提供的用户接口,将提供用户输入事件的回调。
GestureBinding._handlePointerDataPacket
1 | void _handlePointerDataPacket(ui.PointerDataPacket packet) { |
从 flutter 引擎中得到的触摸数据将会被转换成一系列的 PointerEvent 数据并传入到 PointerEvent 队列中,在处理时会做同步控制
GestureBinding._flushPointerEventQueue
1 | void _flushPointerEventQueue() { |
该方法会不断取队首的 PointerEvent 事件处理,直到队列为空
GestureBinding._handlePointerEvent
1 | void _handlePointerEvent(PointerEvent event) { |
首先要确定会响应触摸事件的目标,HitTestResult 中就存储着响应触摸事件的目标,在 PointerDownEvent 事件被触发后,理论上会通过触摸范围内的控件沿着树状结构向下查找能响应的目标后就会把后续事件传递给它通过 dispatchEvent 处理(遍历 HitTestResult.path,传递 event 事件给目标控件),直到 PointerUpEvent 或者 PointerCancelEvent 事件。但是这里的 hitTest 仅仅是将 GestureBinding 自身作为一个 entry 传入到了调用链,并没有涉及到树结构的查找操作。
回到 WidgetsFlutterBinding,既然在 GestureBinding 中找不到相关的功能代码实现,那么可以在其他的几个 Binding 类中查找到相关实现。最终,我们发现在 RendererBinding 中混入了 HitTestable 的实现
RendererBinding.hitTest
1 | @override |
renderView 是作为整应用界面的根节点存在,会向下查找(即界面 z 轴向上查找),最后才会调用 GestureBinding 的 hitTest 方法
RenderView.hitTest
1 | bool hitTest(HitTestResult result, { Offset position }) { |
先调用子节点的 hitTest 方法判断是否包含触摸事件,再把自身添加到路径上。而子节点一般而言也是继承自 renderBox 的实现
RenderBox.hitTest
1 | bool hitTest(HitTestResult result, { @required Offset position }) { |
如果给定的位置并没有包含在节点范围内,那么直接返回 false,否则按照 z 轴从顶层开始进行命中测试,调用 hitTest,将命中的节点加入到 result 中,只有当所有的节点包括自身节点都没命中才会返回 false。就这样,我们构建出了一条 目标节点——父节点——跟节点——GestureBinding
的命中路径。
回到 GestureBinding 中,_handlePointerEvent 方法中通过 hitTest 确定了该触摸事件的路径,并存储在 result 中,最后通过 dispatchEvent 开始了分发流程
GestureBinding.dispatchEvent
1 | @override |
该方法会遍历节点路径,并将事件交由对应节点 handleEvent 去处理,行成了一个冒泡事件处理,RenderBox 中的 handleEvent 是空实现,应该是交由继承的子类实现具体的逻辑。而 RenderView 也没啥特殊处理,GestureBinding 中有处理实现
1 | @override |
pointerRouter、gestureArena 则跟手势判断有关了,后面会提到
手势判断
那么触摸事件是如何转换为点击事件的呢,这就必须提到 flutter 中的 GestureDetector。在该 widget 中,会为出入的同类型手势监听生成 GestureRecognizer —— 手势识别器,以 onTap 点击监听为例
1 | if (onTapDown != null || onTapUp != null || onTap != null || onTapCancel != null) { |
如果存在 tap 相关监听都会生成 TapGestureRecognizer,最后 gestures 会被传入到生成的 RawGestureDetector 中进行手势判断。
RawGestureDetector 是一个 StatefulWidget,所以在手势的回调方法中可以通过 setState 方法更新界面,而里面也有一些 GestureRecognizer 的更新逻辑
RawGestureDetector.build
1 | @override |
build 方法返回了一个 Listener 控件,并传入了 _handlePointerDown 的触摸事件回调。结合上文所述,深入追踪以后就会发现 Listener 中 createRenderObject 创建的 RenderPointerListener 中 handleEvent 方法会在 PointerDownEvent 分发时回调我们传入的 _handlePointerDown 方法,这样就能接受一个初始的 down 事件。但是,我们发现并没有接受其他的事件,所以现在回过头来看 _handlePointerDown
RawGestureDetector._handlePointerDown
1 | void _handlePointerDown(PointerDownEvent event) { |
会遍历所有的手势识别器并将 PointerDownEvent 传入,该方法会根据 pointer 注册到 GestureBinding 的 pointerRouter 来接受后续的 PointerEvent,同时也会 GestureBinding 的 gestureArena 里根据 pointer 注册自己
PointerRouter
PointerRouter 相对比较简单
PointerRouter.addRoute
1 | void addRoute(int pointer, PointerRoute route) { |
它会将对应的 pointer 的回调方法传入到一个 LinkedHashSet 中,在 GestureBinding 的 handleEvent 中,会调用到 pointerRouter.route 方法
1 | void route(PointerEvent event) { |
这是会调用传入的回调方法,追踪 GestureRecognizer 的一个抽象子类 OneSequenceGestureRecognizer 就能发现回调方法是个抽象的 handleEvent 方法,需要子类去实现具体逻辑,这样 GestureRecognizer 只要实现了该方法就能追踪后续的 PointEvent 事件了
OneSequenceGestureRecognizer.startTrackingPointer
1 | @protected |
startTrackingPointer 方法一般由子类实现的 addPointer 方法中调用,可以理解为开始追踪指定的触摸事件
GestureArenaManager
这个类很有意思,字面意思就是手势竞技场,所有的手势识别器都会被放到这个手势竞技场里,知道出现第一个接受手势的成员或者是最后一个没有拒绝手势的成员为获胜者,具体的来看
GestureArenaManager.add
1 | GestureArenaEntry add(int pointer, GestureArenaMember member) { |
每个 pointer 都会生成一个 _GestureArena,而 GestureRecognizer 继承自 GestureArenaMember,所以每次 GestureRecognizer 开始追踪时都会把自己加入到这个竞技场中,每个竞技场中可能有多个手势识别器在互相竞争,这个加入的动作也是在 OneSequenceGestureRecognizer 的 startTrackingPointer 方法中调用的
OneSequenceGestureRecognizer._addPointerToArena
1 | GestureArenaEntry _addPointerToArena(int pointer) { |
返回 GestureArenaEntry 则是用来对竞技场进行操作的,一共有两种操作,一种是 rejected 表示该手势退出了竞争,另一种是 accepted 已经确认是这种手势,具体 GestureArena 是如何判断获胜者的呢,我们追踪到 GestureArena._resolve 方法
GestureArena._resolve
1 | void _resolve(int pointer, GestureArenaMember member, GestureDisposition disposition) { |
关于这个 state.isOpen 比较特殊,PointEvent 分发总是先目标节点,再 GestureBinding (见上文),PointDownEvent 事件总是先触发 GestureRecognizer 在 GestureArena 的注册,然后在 GestureBinding.handleEvent 中调用 GestureArenaManager.close 方法将
state.isOpen 置为 false,这个过程类似于PointDownEvent 触发了竞技场入场仪式,各个选手开始进入竞技场,并在最后时刻关闭竞技场的大门,开始了精彩的对决,直到决出胜者
当有 GestureRecognizer 拒绝时,他本身会从竞技场移除,并回调 rejectGesture 方法,同时也会启动 _tryToResolveArena 方法,目的就是找出最后一个没有拒绝的 GestureRecognizer 自动成为获胜者。如果 GestureRecognizer 接受了这个事件,那么就会调用 _resolveInFavorOf 方法宣布获胜者
GestureArenaManager._tryToResolveArena
1 | void _tryToResolveArena(int pointer, _GestureArena state) { |
当竞技场中的选手只有一位的时候会 scheduleMicrotask 的形式去执行 _resolveByDefault 方法,这是 dart 中消息队列提交方法,可以用来执行异步任务,它会等待当前的代码都执行完毕以后才会执行队列中的方法,为的是防止最后一个成员也放弃的情况下错误把它当成获胜者
GestureArenaManager._resolveByDefault
1 | void _resolveByDefault(int pointer, _GestureArena state) { |
开头的判空就是为了处理所有成员都放弃的情况,只有只剩下一个成员且改成员并没有放弃的时候才能把他当做获胜者,接着就是移除竞技场,执行获胜回调 acceptGesture。而另一方面,第一个宣布接受的人也能成为获胜者
GestureArenaManager._resolveInFavorOf
1 | void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) { |
这里会移除竞技场,并将其他的成员宣布为失败者,执行 rejectGesture 回调,而胜利者则会执行 acceptGesture 回调
但是如果竞技场内一直都没有决出胜者,也没有成员放弃,一直僵持着会发生什么事呢,还记得在 GestureBinding.handleEvent 中,PointUpEvent 会触发 GestureArenaManager.sweep 方法,该方法可以清扫竞技场来强制确定获胜者
1 | void sweep(int pointer) { |
第一个成员将成为获胜者,其他的则会收到失败的消息,所以当有同类型的手势识别嵌套时,根据 PointEvent 事件的分发规律以及手势识别的竞争规律可以得知只有最顶层的 widget 才会响应
同时可以看到通过 hold 方法可以延迟清扫,保证类似于 DoubleTapGestureRecognizer 这种需要维持两个触摸周期的的手势识别器能正常判断,之后需要通过调用 release 来手动清扫
下面就以最常见的 TapGestureRecognizer 为例,继承自 PrimaryPointerGestureRecognizer
PrimaryPointerGestureRecognizer.addPointer
1 | void addPointer(PointerDownEvent event) { |
追踪 pointer 后续事件以及添加手势识别到 GestureArenaManager 中,将状态转换为 possible,记录 pointer 和 初始位置,最后会启动一个超时回调,这就是追踪开始的初始化工作。这里说明下 GestureRecognizerState 一共有 ready、possible 和 defunct 三种状态,其中 defunct 表示已经裁决出手势
再转到 handleEvent 看看是如何处理后续事件的
PrimaryPointerGestureRecognizer.handleEvent
1 | void handleEvent(PointerEvent event) { |
可以看到如果滑动的距离大于了阀值,就认为不是点击事件从而放弃竞争手势,再看看 handlePrimaryPointer 实现是在 TapGestureRecognizer 中
TapGestureRecognizer.handlePrimaryPointer
1 | void handlePrimaryPointer(PointerEvent event) { |
_reset 方法是重置状态,所以确认手势发生在 _checkUp 方法里
TapGestureRecognizer._checkUp
1 | void _checkUp() { |
其实这里有两个条件,一个是 _wonArenaForPrimaryPointer 为 true 还有一个是 _finalPosition 不为空,_finalPosition 会在 up 事件中赋值,但是 _wonArenaForPrimaryPointer 属性只有在 acceptGesture 方法中才能置为 true,所以可以看到触发 onTap 事件只有在赢得手势竞争且为抬起手势时才会被触发。
从这里可以看出 onTap 事件并不是由自己去争取的。所以按照上面的说法只有两种情况才能出发 onTap,等待 GestureArenaManager.sweep 触发选择第一个作为获胜者或者 其他手势放弃后只剩 TapGestureRecognizer 作为胜利者,但是会等待 PointUpEvent 触发,会同时调用 onTap 和 onTapUp 回调。onTapDown 的回调触发就比较特别了,在 acceptGesture 中会先行触发 onTapDown 回调,但是 acceptGesture 对 TapGestureRecognizer 来说一般触发的时间都会比较晚,往往比不是 onTapDown 的好时机,所以回到 PrimaryPointerGestureRecognizer.addPointer,还记得方法中启动了一个定时器,TapGestureRecognizer 设定的时间为 kPressTimeout 100ms,时间到之后就会回调 onTapDown 了,而这个定时器在 LongPressGestureRecognizer 里就摇身一变成为了判定是否长按的依据了,有兴趣也可以自己去看
结语
flutter 的这套界面触碰架构非常有意思,虽然比较复杂,但是并不是很难理解